Skip to content

Comments

[do not merge] feat: Span streaming & new span API#5317

Draft
sentrivana wants to merge 129 commits intomasterfrom
feat/span-first-2
Draft

[do not merge] feat: Span streaming & new span API#5317
sentrivana wants to merge 129 commits intomasterfrom
feat/span-first-2

Conversation

@sentrivana
Copy link
Contributor

@sentrivana sentrivana commented Jan 15, 2026

Introduce a new start_span() API with a simpler and more intuitive signature to eventually replace the original start_span() and start_transaction() APIs.

Additionally, introduce a new streaming mode (sentry_sdk.init(_experiments={"trace_lifecycle": "stream"})) that will send spans as they finish, rather than by transaction.

import sentry_sdk

sentry_sdk.init(
    _experiments={"trace_lifecycle": "stream"},
)

with sentry_sdk.traces.start_span(name="my_span"):
    ...

The new API MUST be used with the new streaming mode, and the old API MUST be used in the legacy non-streaming (static) mode.

Migration guide: getsentry/sentry-docs#16072

Notes

  • The diff is huge mostly because I've optimized for easy removal of legacy code in the next major, deliberately duplicating a lot. I'll of course split it up to reviewable PRs once ready.
    • Chose to go with a new file and a new span class so that we can just remove the old Span and drop the new StreamedSpan in tracing.py as a replacement.
  • The batcher for spans is a bit different from the logs and metrics batchers because it needs to batch by trace_id (we can't send spans from different traces in the same envelope).

Release Plan

  • There will be prereleases for internal testing.
  • We'll release the new API in a minor version as opt-in.
  • In the next major, we'll drop the legacy API.

Project

https://linear.app/getsentry/project/span-first-sdk-python-727da28dd037/overview

@github-actions
Copy link
Contributor

github-actions bot commented Jan 15, 2026

Semver Impact of This PR

None (no version bump detected)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


Bug Fixes 🐛

Openai

  • Avoid consuming iterables passed to the Completions API by alexander-alderman-webb in #5489
  • Avoid consuming iterables passed to the Embeddings API by alexander-alderman-webb in #5491

Other

  • (grpc) Read method from handler_call_details for grpcio >= 1.76 compat by yeung108 in #5521
  • (pydantic-ai) Adapt to missing ToolManager._call_tool by sentrivana in #5522
  • (utils) Use HEROKU_BUILD_COMMIT env var for default release by ericapisani in #5499

Documentation 📚

  • Add debugging advice by alexander-alderman-webb in #5517

Internal Changes 🔧

  • (agents) Add security-review skill to agent configuration by ericapisani in #5498
  • (anthropic) Remove set_data_normalized for primitive attributes by alexander-alderman-webb in #5504
  • (openai-agents) Remove set_data_normalized for primitive attributes by alexander-alderman-webb in #5509
  • (pydantic-ai) Remove set_data_normalized for the gen_ai.response.model attribute by alexander-alderman-webb in #5512
  • 🤖 Update test matrix with new releases (02/24) by github-actions in #5524
  • 🤖 Update test matrix with new releases (02/23) by github-actions in #5503

Other

  • [do not merge] feat: Span streaming & new span API by sentrivana in #5317

🤖 This preview updates automatically when you update the PR.

Comment on lines +332 to +339
transaction = sentry_sdk.traces.start_span(
name="unknown celery task"
)
transaction.set_origin(CeleryIntegration.origin)
transaction.set_source(TransactionSource.TASK)
transaction.set_op(OP.QUEUE_TASK_CELERY)

span_ctx = transaction
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Celery task name not set in span streaming mode

In the span streaming path, the transaction is created with name="unknown celery task" but never updated to the actual task name. The non-streaming path sets transaction.name = task.name (line 349), but the streaming path is missing the equivalent transaction.set_name(task.name) call. This will cause all Celery tasks in span streaming mode to be reported as "unknown celery task" in Sentry, making task identification and debugging impossible.

Verification

Read sentry_sdk/integrations/celery/init.py lines 303-370 to compare the span_streaming and non-streaming code paths. Verified StreamedSpan has a set_name() method (sentry_sdk/traces.py line 478-479) that should be called. Non-streaming path explicitly sets transaction.name = task.name on line 349, but span_streaming path lacks equivalent.

Suggested fix: Add set_name(task.name) call after creating the span in the span streaming path

Suggested change
transaction = sentry_sdk.traces.start_span(
name="unknown celery task"
)
transaction.set_origin(CeleryIntegration.origin)
transaction.set_source(TransactionSource.TASK)
transaction.set_op(OP.QUEUE_TASK_CELERY)
span_ctx = transaction
transaction.set_name(task.name)

Identified by Warden code-review · 8DB-RN4

Comment on lines +572 to 574
if isinstance(span, Span) and span.status == SPANSTATUS.INTERNAL_ERROR:
with capture_internal_exceptions():
span.__exit__(None, None, None)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StreamedSpan not properly closed on error in Anthropic integration

The change filters out StreamedSpan instances from error cleanup, but doesn't add equivalent handling for the new span type. When streaming mode is enabled (trace_lifecycle: stream) and an exception occurs during Anthropic API calls, the StreamedSpan will have its status set to SpanStatus.ERROR by set_span_errored(), but __exit__ will never be called to properly end the span. This leaves the span unclosed and potentially causes incomplete trace data.

Verification

Read sentry_sdk/integrations/anthropic.py lines 540-614 to understand the span lifecycle. Verified that span is created via get_start_span_function() which can return StreamedSpan when streaming mode is enabled (sentry_sdk/ai/utils.py:535-547). Confirmed StreamedSpan uses get_status() method and SpanStatus.ERROR (not .status attribute with SPANSTATUS.INTERNAL_ERROR) per sentry_sdk/traces.py:455-465. Checked set_span_errored() in sentry_sdk/tracing_utils.py:1109-1126 confirms it sets SpanStatus.ERROR on StreamedSpan. The isinstance(span, Span) check excludes StreamedSpan from exit cleanup.

Also found at 1 additional location
  • sentry_sdk/integrations/celery/__init__.py:104-105

Identified by Warden code-review · SR8-YQM

# to add @functools.wraps(f) here.
# https://github.com/getsentry/sentry-python/issues/421
@ensure_integration_enabled(CeleryIntegration, f)
def _inner(*args: "Any", **kwargs: "Any") -> "Any":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @wraps(f) decorator after removing @ensure_integration_enabled breaks celery-once compatibility

The @ensure_integration_enabled(CeleryIntegration, f) decorator was removed from _inner, but @functools.wraps(f) was not added. The comment at lines 395-399 explicitly warns: "functools.wraps is important here because celery-once looks at this method's name... but if we ever remove the @ensure_integration_enabled decorator, we need to add @functools.wraps(f) here." Without @wraps(f), _inner.__name__ will be '_inner' instead of the original function's name, breaking celery-once integration.

Verification

Read the _wrap_task_call function in sentry_sdk/integrations/celery/init.py (lines 391-464). Verified the ensure_integration_enabled decorator in sentry_sdk/utils.py (lines 1837-1849) applies wraps(original_function) at line 1847. The comment at lines 395-399 explicitly documents this requirement. The decorator was removed but @wraps(f) was not added to _inner.

Suggested fix: Add @wraps(f) decorator to the _inner function to preserve the original function's metadata

Suggested change
def _inner(*args: "Any", **kwargs: "Any") -> "Any":
@wraps(f)

Identified by Warden code-review · 7EE-X7V

name=sentry_span_name, parent_span=parent_sentry_span
)
sentry_span.set_op("function")
sentry_span.set_origin("origin")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_origin uses literal string "origin" instead of self.origin

On line 220, sentry_span.set_origin("origin") uses the hardcoded string literal "origin" instead of self.origin. This is inconsistent with all other branches in the same function that correctly use self.origin (lines 225, 235, 240). This causes spans created under a StreamedSpan parent to have an incorrect origin attribute ("origin" instead of the properly configured value like "auto.function.rust_tracing.{identifier}").

Verification

Read the full file sentry_sdk/integrations/rust_tracing.py. Confirmed self.origin is initialized in RustTracingLayer.init (line 159) and is used correctly in all other code paths: line 225 (start_child), line 235 (set_origin with no parent in streaming mode), line 240 (start_span with no parent in non-streaming mode). Only line 220 incorrectly uses the string literal.

Suggested fix: Change the string literal "origin" to self.origin

Suggested change
sentry_span.set_origin("origin")
sentry_span.set_origin(self.origin)

Identified by Warden code-review · 8Y6-99J

span.set_attribute(SPANDATA.NETWORK_PEER_ADDRESS, self.host)
span.set_attribute(SPANDATA.NETWORK_PEER_PORT, self.port)

span.start()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NoOpStreamedSpan.finish() does not restore scope state

When span_streaming is enabled and a NoOpStreamedSpan is returned (e.g., for ignored spans), calling span.start() followed by span.finish() does not properly restore the scope state. NoOpStreamedSpan.start() sets scope.span = self and saves the old span in _context_manager_state, but NoOpStreamedSpan.finish() is a no-op (pass), so scope.span is never restored. This could cause subsequent spans to be incorrectly parented to the no-op span.

Verification

Verified by reading sentry_sdk/traces.py: NoOpStreamedSpan.finish() (line 727-728) is pass, while NoOpStreamedSpan.start() (line 721-722) calls enter() which sets scope.span. The getresponse() function at line 183 calls span.finish() which won't clean up for NoOpStreamedSpan.

Suggested fix: Call span.end() instead of span.finish() since NoOpStreamedSpan.end() properly calls exit() to restore scope state, or fix NoOpStreamedSpan.finish() to also call exit()

Suggested change
span.start()
if isinstance(span, StreamedSpan):
span.end()
else:
span.finish()

Identified by Warden code-review · FYA-VCQ

Comment on lines +261 to +263
self.parsing_span = sentry_sdk.traces.start_span(
name="parsing",
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing parent_span in on_parse() causes orphaned spans in streaming mode

In on_parse(), the start_span() call for streaming mode (lines 261-263) does not pass parent_span=self.graphql_span, unlike the equivalent code in on_validate() which correctly passes it. This means parsing spans will not be correctly parented to the GraphQL operation span in streaming mode, causing broken trace hierarchy and incorrect trace visualization in Sentry.

Verification

Compared on_parse() streaming path (line 261-263) with on_validate() streaming path (line 239-241) in strawberry.py. Also verified against resolve() methods in both SentryAsyncExtension (line 314-315) and SentrySyncExtension (line 352-353) which all correctly pass parent_span. Confirmed start_span API signature in sentry_sdk/traces.py (line 105-109) which shows parent_span is optional and defaults to None (current active span).

Suggested fix: Add parent_span=self.graphql_span to the start_span() call in on_parse() for streaming mode, matching the pattern used in on_validate().

Suggested change
self.parsing_span = sentry_sdk.traces.start_span(
name="parsing",
)
parent_span=self.graphql_span,

Identified by Warden code-review · 9Y5-4C6

if isinstance(self.graphql_span, StreamedSpan):
span = sentry_sdk.traces.start_span(
parent_span=self.graphql_span,
name="resolving {field_path}",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing f-string prefix causes span name to be literal text instead of formatted

In SentrySyncExtension.resolve, the span name is set to "resolving {field_path}" (line 354) but the f prefix is missing. This means the span name will literally be "resolving {field_path}" instead of being interpolated with the actual field path value (e.g., "resolving Query.users"). The async version on line 315 correctly uses an f-string. This will make it difficult to identify which resolver a span corresponds to in Sentry.

Verification

Read the full file and compared line 354 (name="resolving {field_path}") with line 315 (name=f"resolving {field_path}"). The async version has the f prefix while the sync version is missing it.

Suggested fix: Add the f prefix to make it an f-string

Suggested change
name="resolving {field_path}",
name=f"resolving {field_path}",

Identified by Warden code-review · D84-YUV

Comment on lines +418 to +419
if self.sampled is None:
logger.warning("Discarding transaction without sampling decision.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Span not discarded despite 'discarding' warning when sampled is None

At line 418-419, when self.sampled is None, the code logs 'Discarding transaction without sampling decision' but does not return. This allows the span to continue processing and potentially be captured at line 434-435. The legacy implementation in tracing.py (line 1036-1038) correctly returns None after this warning. This inconsistency means spans without sampling decisions may be sent when they should be discarded.

Verification

Compared to sentry_sdk/tracing.py lines 1036-1038 which has return None after the same warning. In traces.py, after line 419 the code continues to line 421 and may reach line 434-435 where the span is captured.

Suggested fix: Add a return statement after the warning to actually discard the span, matching the legacy behavior in tracing.py

Suggested change
if self.sampled is None:
logger.warning("Discarding transaction without sampling decision.")
return

Identified by Warden code-review · DBB-FK8

Comment on lines +670 to +676
class NoOpStreamedSpan(StreamedSpan):
__slots__ = (
"_name",
"segment",
"_scope",
"_context_manager_state",
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NoOpStreamedSpan missing method overrides will cause AttributeError

NoOpStreamedSpan inherits from StreamedSpan but does not override dynamic_sampling_context(), get_baggage(), or to_baggage(). Since NoOpStreamedSpan sets self.segment = None and does not define _baggage in its __slots__, calling these inherited methods will raise AttributeError. For example, dynamic_sampling_context() at line 524-525 calls self.segment.get_baggage() which will fail with 'NoneType' object has no attribute 'get_baggage'.

Verification

Verified by reading NoOpStreamedSpan class definition (lines 670-776) and its parent StreamedSpan. NoOpStreamedSpan's slots (lines 671-676) does not include '_baggage', and init sets segment=None. The methods dynamic_sampling_context() (line 524-525), get_baggage() (lines 573-582) are not overridden.

Identified by Warden code-review · RKZ-FNB

assert segment2["is_segment"] is True
assert segment2["parent_span_id"] is None

assert segment1["trace_id"] == segment1["trace_id"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tautological assertion compares segment1 trace_id to itself

Line 500 asserts segment1["trace_id"] == segment1["trace_id"] which always passes. Based on the test name test_sibling_segments and the pattern in the subsequent test test_sibling_segments_new_trace (which asserts segment1["trace_id"] != segment2["trace_id"]), this should verify that sibling segments without a new_trace() call share the same trace_id: assert segment1["trace_id"] == segment2["trace_id"]. The test currently provides no coverage for this expected behavior.

Verification

Read lines 470-535 of test_span_streaming.py. Compared test_sibling_segments (line 470) with test_sibling_segments_new_trace (line 503). The latter correctly asserts segment1["trace_id"] != segment2["trace_id"] on line 535, confirming the pattern. The test name and structure indicate line 500 should assert both segments share the same trace_id.

Suggested fix: Compare segment1's trace_id with segment2's trace_id instead of itself

Suggested change
assert segment1["trace_id"] == segment1["trace_id"]
assert segment1["trace_id"] == segment2["trace_id"]

Identified by Warden code-review · PVK-5ZM

if isinstance(current_span, StreamedSpan) or has_span_streaming_enabled(
client.options
):
return sentry_sdk.traces.start_span
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_start_span_function returns incompatible function signature in streaming mode

When span streaming is enabled, get_start_span_function() returns sentry_sdk.traces.start_span which only accepts name, attributes, and parent_span parameters. However, callers (google_genai, anthropic, langchain, pydantic_ai, mcp, litellm integrations) pass op, name, and origin as keyword arguments. This will cause a TypeError at runtime when users enable streaming mode with _experiments={"trace_lifecycle": "stream"} and any of these AI integrations are used.

Verification

Verified by reading sentry_sdk/traces.py lines 105-156 showing start_span signature accepts only (name, attributes, parent_span). Traced callers via grep for get_start_span_function() showing integrations pass op= and origin= kwargs (e.g., google_genai/init.py:76-80, anthropic.py:408-412, langchain.py:982-985). The signature mismatch will raise TypeError when streaming mode is enabled.

Suggested fix: Update sentry_sdk.traces.start_span to accept and handle **kwargs, or update the function to accept op and origin parameters and forward them appropriately

Suggested change
return sentry_sdk.traces.start_span
op: "Optional[str]" = None,
origin: "Optional[str]" = None,
span = sentry_sdk.get_current_scope().start_streamed_span(
if op is not None:
span.set_op(op)
if origin is not None:
span.set_origin(origin)
return span
Also found at 2 additional locations
  • sentry_sdk/integrations/anthropic.py:610-612
  • sentry_sdk/integrations/celery/__init__.py:104-105

Identified by Warden find-bugs · UV8-FTU

Comment on lines +151 to +156
_graphql_span = sentry_sdk.traces.start_span(name=operation_name or "operation")
_graphql_span.set_op(op)
_graphql_span.set_attribute("graphql.document", source)
if operation_name:
_graphql_span.set_attribute("graphql.operation.name", operation_name)
_graphql_span.set_attribute("graphql.operation.type", operation_type)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StreamedSpan created without entering context manager causes silent span loss

In streaming mode, the code creates a StreamedSpan via sentry_sdk.traces.start_span() but never enters its context manager (no .start() call or with statement). When .finish() is called in the finally block, it invokes __exit__() which tries to access self._context_manager_state - an attribute only set in __enter__(). Since this access is wrapped in capture_internal_exceptions(), the AttributeError is silently caught and logged, but _end() is never called. This means GraphQL spans in streaming mode are never actually sent to Sentry.

Verification

Verified by tracing the code path: 1) sentry_sdk.traces.start_span() returns a StreamedSpan without calling __enter__. 2) StreamedSpan.__init__ does not set _context_manager_state. 3) _graphql_span.finish() (line 166) calls end() which calls __exit__(). 4) __exit__ (traces.py:346-350) accesses self._context_manager_state inside capture_internal_exceptions(), which swallows the AttributeError. 5) _end() is never reached because the exception is caught. Confirmed other integrations (e.g., celery/init.py:294) correctly use with span_mgr as span: pattern.

Suggested fix: Use the span as a context manager by calling .start() before the try/yield block and using .end() in the finally block, matching the pattern used in other integrations like Celery.

Suggested change
_graphql_span = sentry_sdk.traces.start_span(name=operation_name or "operation")
_graphql_span.set_op(op)
_graphql_span.set_attribute("graphql.document", source)
if operation_name:
_graphql_span.set_attribute("graphql.operation.name", operation_name)
_graphql_span.set_attribute("graphql.operation.type", operation_type)
_graphql_span.start()
_graphql_span.end()
Also found at 1 additional location
  • sentry_sdk/integrations/stdlib.py:183-183

Identified by Warden find-bugs · H3C-HLQ

Comment on lines +324 to +368
span_ctx: "Union[Span, StreamedSpan]"

# Celery task objects are not a thing to be trusted. Even
# something such as attribute access can fail.
with capture_internal_exceptions():
headers = args[3].get("headers") or {}
transaction = continue_trace(
headers,
op=OP.QUEUE_TASK_CELERY,
name="unknown celery task",
source=TransactionSource.TASK,
origin=CeleryIntegration.origin,
)
transaction.name = task.name
transaction.set_status(SPANSTATUS.OK)
if span_streaming:
sentry_sdk.traces.continue_trace(headers)
transaction = sentry_sdk.traces.start_span(
name="unknown celery task"
)
transaction.set_origin(CeleryIntegration.origin)
transaction.set_source(TransactionSource.TASK)
transaction.set_op(OP.QUEUE_TASK_CELERY)

span_ctx = transaction

else:
transaction = continue_trace(
headers,
op=OP.QUEUE_TASK_CELERY,
name="unknown celery task",
source=TransactionSource.TASK,
origin=CeleryIntegration.origin,
)
transaction.name = task.name
transaction.set_status(SPANSTATUS.OK)

span_ctx = sentry_sdk.start_transaction(
transaction,
custom_sampling_context={
"celery_job": {
"task": task.name,
# for some reason, args[1] is a list if non-empty but a
# tuple if empty
"args": list(args[1]),
"kwargs": args[2],
}
},
)

if transaction is None:
return f(*args, **kwargs)

with sentry_sdk.start_transaction(
transaction,
custom_sampling_context={
"celery_job": {
"task": task.name,
# for some reason, args[1] is a list if non-empty but a
# tuple if empty
"args": list(args[1]),
"kwargs": args[2],
}
},
):
with span_ctx:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential UnboundLocalError for span_ctx variable if exception occurs

The variable span_ctx is declared but not initialized. Inside capture_internal_exceptions(), if an exception occurs after transaction = start_span(...) but before span_ctx = transaction (e.g., during set_origin, set_source, or set_op calls), the transaction is None check passes but span_ctx remains unbound. This would cause an UnboundLocalError at with span_ctx: on line 368.

Verification

Traced the control flow: span_ctx declared on line 324 without initialization. In span_streaming path, span_ctx = transaction is assigned on line 339. If exception occurs at lines 335-337 (set_origin/set_source/set_op), transaction is set but span_ctx is not. The check at line 365 only checks transaction is None, so execution proceeds to line 368 where span_ctx is used uninitialized.

Suggested fix: Initialize span_ctx to None and check both transaction and span_ctx before using

Suggested change
span_ctx: "Union[Span, StreamedSpan]"
# Celery task objects are not a thing to be trusted. Even
# something such as attribute access can fail.
with capture_internal_exceptions():
headers = args[3].get("headers") or {}
transaction = continue_trace(
headers,
op=OP.QUEUE_TASK_CELERY,
name="unknown celery task",
source=TransactionSource.TASK,
origin=CeleryIntegration.origin,
)
transaction.name = task.name
transaction.set_status(SPANSTATUS.OK)
if span_streaming:
sentry_sdk.traces.continue_trace(headers)
transaction = sentry_sdk.traces.start_span(
name="unknown celery task"
)
transaction.set_origin(CeleryIntegration.origin)
transaction.set_source(TransactionSource.TASK)
transaction.set_op(OP.QUEUE_TASK_CELERY)
span_ctx = transaction
else:
transaction = continue_trace(
headers,
op=OP.QUEUE_TASK_CELERY,
name="unknown celery task",
source=TransactionSource.TASK,
origin=CeleryIntegration.origin,
)
transaction.name = task.name
transaction.set_status(SPANSTATUS.OK)
span_ctx = sentry_sdk.start_transaction(
transaction,
custom_sampling_context={
"celery_job": {
"task": task.name,
# for some reason, args[1] is a list if non-empty but a
# tuple if empty
"args": list(args[1]),
"kwargs": args[2],
}
},
)
if transaction is None:
return f(*args, **kwargs)
with sentry_sdk.start_transaction(
transaction,
custom_sampling_context={
"celery_job": {
"task": task.name,
# for some reason, args[1] is a list if non-empty but a
# tuple if empty
"args": list(args[1]),
"kwargs": args[2],
}
},
):
with span_ctx:
span_ctx: "Optional[Union[Span, StreamedSpan]]" = None
if transaction is None or span_ctx is None:

Identified by Warden find-bugs · SWU-CK2

Comment on lines +828 to +831
if isinstance(self._span, StreamedSpan):
self._span.segment.set_name(name)
if source:
self._span.segment.set_source(source)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_transaction_name crashes when scope has NoOpStreamedSpan due to None segment

When self._span is a NoOpStreamedSpan, isinstance(self._span, StreamedSpan) returns True (since NoOpStreamedSpan inherits from StreamedSpan), but NoOpStreamedSpan.segment is set to None in its __init__. This causes self._span.segment.set_name(name) to raise AttributeError: 'NoneType' object has no attribute 'set_name'. Users can trigger this by calling scope.set_transaction_name() while a NoOpStreamedSpan is the active span (returned when spans are ignored or the SDK is misconfigured).

Verification

Traced code path: 1) NoOpStreamedSpan.init sets self.segment = None (traces.py:681), 2) NoOpStreamedSpan.enter sets scope.span = self (traces.py:693), 3) set_transaction_name checks isinstance(self._span, StreamedSpan) which is True for NoOpStreamedSpan, 4) Accessing self._span.segment.set_name() fails because segment is None.

Suggested fix: Add a check for NoOpStreamedSpan before accessing segment, or check if segment is not None

Suggested change
if isinstance(self._span, StreamedSpan):
self._span.segment.set_name(name)
if source:
self._span.segment.set_source(source)
if isinstance(self._span, StreamedSpan) and not isinstance(self._span, NoOpStreamedSpan):
Also found at 1 additional location
  • sentry_sdk/scope.py:1201-1203

Identified by Warden find-bugs · 2PK-MMZ


new_trace() doesn't start any spans on its own.
"""
sentry_sdk.get_current_scope().set_new_propagation_context()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new_trace() doesn't reset propagation context on isolation scope, unlike continue_trace()

The continue_trace() function explicitly sets propagation context on both the isolation scope and current scope (with a comment explaining this is needed 'for compatibility reasons' in span-first mode). However, new_trace() only calls set_new_propagation_context() on the current scope. This asymmetry could lead to stale trace information persisting on the isolation scope when starting a new trace, potentially causing spans to be incorrectly associated with old traces in certain scope configurations.

Verification

Verified by reading both functions in sentry_sdk/traces.py (lines 159-191) and the comment in continue_trace() at lines 169-173 which explicitly states both scopes need to be set. Also verified that set_new_propagation_context() exists and is used on isolation scope in other parts of the codebase (e.g., sentry_sdk/integrations/celery/beat.py line 192).

Suggested fix: Call set_new_propagation_context() on both isolation scope and current scope for consistency with continue_trace()

Suggested change
sentry_sdk.get_current_scope().set_new_propagation_context()
sentry_sdk.get_isolation_scope().set_new_propagation_context()

Identified by Warden find-bugs · 3EK-9CE

Comment on lines +1498 to +1517
elif isinstance(rule, dict):
name_matches = True
attributes_match = True

if "name" in rule:
name_matches = _matches(rule["name"], name)

if "attributes" in rule:
if not attributes:
attributes_match = False
else:
for attribute, value in rule["attributes"].items():
if attribute not in attributes or not _matches(
value, attributes[attribute]
):
attributes_match = False
break

if name_matches and attributes_match:
return True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty dict in ignore_spans config ignores all spans unexpectedly

When an empty dict {} is included in the _experiments.ignore_spans config, the is_ignored_span function will return True for all spans. This happens because name_matches and attributes_match both default to True, and without any 'name' or 'attributes' keys to check, the condition name_matches and attributes_match is always satisfied. This is likely a configuration error by users, and the behavior could cause unexpected loss of all tracing data.

Verification

Verified by reading sentry_sdk/tracing_utils.py lines 1498-1517. The dict rule handling initializes name_matches=True and attributes_match=True (lines 1499-1500), then only overwrites them if 'name' or 'attributes' keys exist (lines 1502-1514). If neither key exists, both remain True and the function returns True at line 1517. Checked tests/tracing/test_span_streaming.py and found no test case for empty dict rules.

Suggested fix: Add a check to skip dict rules that have neither 'name' nor 'attributes' keys, treating them as no-ops rather than match-all rules.

Suggested change
elif isinstance(rule, dict):
name_matches = True
attributes_match = True
if "name" in rule:
name_matches = _matches(rule["name"], name)
if "attributes" in rule:
if not attributes:
attributes_match = False
else:
for attribute, value in rule["attributes"].items():
if attribute not in attributes or not _matches(
value, attributes[attribute]
):
attributes_match = False
break
if name_matches and attributes_match:
return True
# Skip empty dict rules - they should not match anything
if "name" not in rule and "attributes" not in rule:
continue

Identified by Warden find-bugs · AY4-S9N

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants